/*
 * Copyright (c) 2024 DuckDuckGo
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.duckduckgo.subscriptions.impl.auth2

import android.net.Uri
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.common.utils.CurrentTimeProvider
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.subscriptions.impl.PrivacyProFeature
import com.squareup.anvil.annotations.ContributesBinding
import dagger.Lazy
import dagger.SingleInstanceIn
import kotlinx.coroutines.withContext
import logcat.logcat
import retrofit2.HttpException
import retrofit2.Response
import java.time.Duration
import java.time.Instant
import javax.inject.Inject

interface AuthClient {
    /**
     * Starts authorization session.
     *
     * @param codeChallenge code challenge derived from code verifier as described in RFC 7636 section 4.2
     * @return authorization session id
     */
    suspend fun authorize(codeChallenge: String): String

    /**
     * Creates an account linked to the active authorization session.
     *
     * @param sessionId authorization session id
     * @return authorization code required to fetch access token
     */
    suspend fun createAccount(sessionId: String): String

    /**
     * Obtains new access and refresh tokens from BE.
     *
     * @param sessionId authorization session id
     * @param authorizationCode authorization code obtained when creating account
     * @param codeVerifier code verifier generated as described in RFC 7636 section 4.1
     * @return [TokenPair] instance containing access and refresh tokens
     */
    suspend fun getTokens(
        sessionId: String,
        authorizationCode: String,
        codeVerifier: String,
    ): TokenPair

    /**
     * Obtains new access and refresh tokens from BE.
     *
     * @param refreshToken refresh token
     * @return [TokenPair] instance containing access and refresh tokens
     */
    suspend fun getTokens(refreshToken: String): TokenPair

    /**
     * Obtains set of public JWKs used to validate JWTs (JWEs) generated by Auth API V2.
     *
     * @return [String] containing JWK set in JSON format (RFC 7517 section 5)
     */
    suspend fun getJwks(): String

    /**
     * Attempts to sign in using Play Store purchase history data.
     *
     * @param sessionId authorization session id
     * @param signature cryptographically signed string that can be verified by the Auth API with public keys published by the Play Store
     * @param googleSignedData signed data that produced the cryptographic signature in the signature param
     * @return authorization code required to fetch access token
     */
    suspend fun storeLogin(
        sessionId: String,
        signature: String,
        googleSignedData: String,
    ): String

    /**
     * Attempts to sign in using V1 access token.
     *
     * @param accessTokenV1 access token obtained from auth API v1
     * @param sessionId authorization session id
     * @return authorization code required to fetch access token
     */
    suspend fun exchangeV1AccessToken(
        accessTokenV1: String,
        sessionId: String,
    ): String

    /**
     * Invalidates the current access token + refresh token pair based on the access token provided.
     * This is meant to work on a best-effort basis, so this method does not throw if the request fails.
     *
     * @param accessTokenV2 access token obtained from auth API v2
     */
    suspend fun tryLogout(accessTokenV2: String)
}

data class TokenPair(
    val accessToken: String,
    val refreshToken: String,
)

@ContributesBinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class AuthClientImpl @Inject constructor(
    private val authService: AuthService,
    private val appBuildConfig: AppBuildConfig,
    private val timeProvider: CurrentTimeProvider,
    private val privacyProFeature: Lazy<PrivacyProFeature>,
    private val dispatchers: DispatcherProvider,
) : AuthClient {

    private var cachedJwks: CachedJwks? = null

    override suspend fun authorize(codeChallenge: String): String {
        val response = authService.authorize(
            responseType = AUTH_V2_RESPONSE_TYPE,
            codeChallenge = codeChallenge,
            codeChallengeMethod = AUTH_V2_CODE_CHALLENGE_METHOD,
            clientId = AUTH_V2_CLIENT_ID,
            redirectUri = AUTH_V2_REDIRECT_URI,
            scope = AUTH_V2_SCOPE,
        )

        if (response.code() == 302) {
            val sessionId = response.headers()
                .values("Set-Cookie")
                .firstOrNull { it.startsWith("ddg_auth_session_id=") }
                ?.substringBefore(';')
                ?.substringAfter('=')

            if (sessionId == null) {
                throw RuntimeException("Failed to extract sessionId")
            }

            return sessionId
        } else {
            throw HttpException(response)
        }
    }

    override suspend fun createAccount(sessionId: String): String {
        val response = authService.createAccount("ddg_auth_session_id=$sessionId")
        return response.getAuthorizationCode()
    }

    override suspend fun getTokens(
        sessionId: String,
        authorizationCode: String,
        codeVerifier: String,
    ): TokenPair {
        val tokensResponse = authService.token(
            grantType = GRANT_TYPE_AUTHORIZATION_CODE,
            clientId = AUTH_V2_CLIENT_ID,
            codeVerifier = codeVerifier,
            code = authorizationCode,
            redirectUri = AUTH_V2_REDIRECT_URI,
            refreshToken = null,
        )
        return TokenPair(
            accessToken = tokensResponse.accessToken,
            refreshToken = tokensResponse.refreshToken,
        )
    }

    override suspend fun getTokens(refreshToken: String): TokenPair {
        val tokensResponse = authService.token(
            grantType = GRANT_TYPE_REFRESH_TOKEN,
            clientId = AUTH_V2_CLIENT_ID,
            codeVerifier = null,
            code = null,
            redirectUri = null,
            refreshToken = refreshToken,
        )
        return TokenPair(
            accessToken = tokensResponse.accessToken,
            refreshToken = tokensResponse.refreshToken,
        )
    }

    override suspend fun getJwks(): String {
        val useCache = withContext(dispatchers.io()) {
            privacyProFeature.get().authApiV2JwksCache().isEnabled()
        }

        return if (useCache) {
            val cachedResult = cachedJwks?.takeIf { it.timestamp + JWKS_CACHE_DURATION > getCurrentTime() }?.jwks

            cachedResult ?: authService.jwks().string()
                .also { cachedJwks = CachedJwks(jwks = it, timestamp = getCurrentTime()) }
        } else {
            authService.jwks().string()
        }
    }

    override suspend fun storeLogin(
        sessionId: String,
        signature: String,
        googleSignedData: String,
    ): String {
        val response = authService.login(
            cookie = "ddg_auth_session_id=$sessionId",
            body = StoreLoginBody(
                method = "signature",
                signature = signature,
                source = "google_play_store",
                googleSignedData = googleSignedData,
                googlePackageName = appBuildConfig.applicationId,
            ),
        )

        return response.getAuthorizationCode()
    }

    override suspend fun exchangeV1AccessToken(
        accessTokenV1: String,
        sessionId: String,
    ): String {
        val response = authService.exchange(
            authorization = "Bearer $accessTokenV1",
            cookie = "ddg_auth_session_id=$sessionId",
        )
        return response.getAuthorizationCode()
    }

    override suspend fun tryLogout(accessTokenV2: String) {
        try {
            authService.logout(authorization = "Bearer $accessTokenV2")
        } catch (e: Exception) {
            logcat { "Logout request failed" }
        }
    }

    private fun Response<Unit>.getAuthorizationCode(): String {
        if (code() == 302) {
            val authorizationCode = headers()
                .values("Location")
                .firstOrNull()
                ?.let { Uri.parse(it) }
                ?.getQueryParameter("code")

            if (authorizationCode == null) {
                throw RuntimeException("Failed to extract authorization code")
            }

            return authorizationCode
        } else {
            throw HttpException(this)
        }
    }

    private fun getCurrentTime(): Instant = Instant.ofEpochMilli(timeProvider.currentTimeMillis())

    private data class CachedJwks(
        val jwks: String,
        val timestamp: Instant,
    )

    private companion object {
        const val AUTH_V2_CLIENT_ID = "f4311287-0121-40e6-8bbd-85c36daf1837"
        const val AUTH_V2_REDIRECT_URI = "com.duckduckgo:/authcb"
        const val AUTH_V2_SCOPE = "privacypro"
        const val AUTH_V2_CODE_CHALLENGE_METHOD = "S256"
        const val AUTH_V2_RESPONSE_TYPE = "code"
        const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
        const val GRANT_TYPE_REFRESH_TOKEN = "refresh_token"
        val JWKS_CACHE_DURATION: Duration = Duration.ofHours(1)
    }
}
